Skip to content

fix(layout): place the first column on the right in RTL sections - #3953

Closed
Nathaniel-260 wants to merge 11 commits into
superdoc:mainfrom
Nathaniel-260:fix/rtl-column-order
Closed

fix(layout): place the first column on the right in RTL sections#3953
Nathaniel-260 wants to merge 11 commits into
superdoc:mainfrom
Nathaniel-260:fix/rtl-column-order

Conversation

@Nathaniel-260

@Nathaniel-260 Nathaniel-260 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Part of #3952 โ€” deliberately not Fixes, so merging this does not auto-close it.

This PR builds the axis: ColumnLayout gains a direction and the geometry mirrors on it. Nothing
populates that field yet. SectionDirectionContext.pageDirection has no producer either, and no
importer reads w:sectPr/w:bidi at all โ€” the section-direction contract was specified and never
implemented on the producing side, which predates this PR. So a real Hebrew or Arabic document
still renders unchanged after this merges, and #3952 should stay open until an importer feeds the
axis. The evidence is in the contracts/src/index.ts thread below.

The problem

A section carrying w:sectPr/w:bidi fills its columns left to right, so the first paragraph of a two-column Hebrew section lands in the left column. Word puts it on the right (ECMA-376 ยง17.6.1).

SectionDirectionContext already documents pageDirection as governing "section-level chrome only (page numbers, columns, gutters)" โ€” but ColumnGeometry measures x from the content-area left edge and no function in the column geometry ever received a direction. The axis was documented and never consumed.

It reads as a selection bug to users, because selection follows the fill order: drag from the column a Hebrew reader starts in (the right one) into the left one, and everything already highlighted below the anchor disappears. #3952 has the measurements.

The change

ColumnLayout gains an optional direction, and buildColumnGeometry mirrors the strip when it is 'rtl'.

Two decisions worth reviewing:

Mirror, don't reverse. index stays the FILL order and only the painted x moves. Every consumer that walks columns 0..n-1 keeps filling in document order, and since getColumnGeometry is the single source for positioning, fill, hit testing, separators, balancing, floating anchors and footnotes all follow from one place.

The mirror axis is the content area, not the strip. Explicit widths are deliberately not scaled to fill the content area, so a strip of explicit columns can underfill it. Mirroring about its own span would leave it pinned to the left margin and merely swap the columns inside it โ€” the page would still read as left-aligned. NormalizedColumnLayout therefore carries the contentWidth it was normalized against; when absent, the mirror falls back to the span, which is exact whenever the columns fill the area (always so in equal mode). A single column is mirrored on the same rule: a no-op when it fills the area, right-aligned when an explicit one does not.

Five consumers that failed silently

None of these produce a type error when the axis is dropped, which is why each has a test that fails without its fix:

Where What went wrong
getColumnAtX Walked the geometry assuming x ascends with the index.
balanceSectionOnPage Reconstructed document order by sorting fragments on ascending x, on the premise that column 0 comes first left to right. Under RTL that premise inverts, so the balancer consumed the trailing column first and wrote the balanced x/y back in that order โ€” scrambling a balanced page's reading order rather than mirroring it. Measured on a 2-column RTL page of 6 paragraphs: [432, 96, 96, 96, 432, 432] instead of [432, 432, 432, 96, 96, 96].
toBalancingColumns Rebuilt the layout field by field and dropped the axis, so a balanced last page laid out left-to-right inside an otherwise right-to-left section.
Footnote column attribution (incrementalLayout) Broke on the first match under the same ascending assumption, so every fragment matched column 0: a page's notes collapsed into one group, the left column's notes printed under the right column, and its own note area stayed empty.
Separator gate (DomPainter) Read "content past the separator" as "content to the right", so a section whose content never left the first column drew a line Word does not draw. This one took two further passes to get right โ€” see below.

ColumnLayoutForAnchor and ParagraphAnchorsContext.columns now declare direction/contentWidth as well. Runtime was already correct โ€” every caller passes a full normalized layout โ€” but neither would have produced a type error if a future edit dropped the fields, which is exactly how toBalancingColumns went wrong.

Per-column gaps are also clamped to >= 0, matching the scalar gap beside them. OOXML cannot express a negative gutter (w:space is unsigned), but a hand-built layout can, and a gap negative enough to pull a column behind its predecessor would make an upright LTR strip answer hit tests as if it were mirrored.

The separator gate needed a second pass (b9e7e51)

The gate asks whether a later column holds content, because Word draws no line beside an empty
column. The first two attempts both answered it by comparing a fragment edge against the
separator x, picking whichever edge trails in the fill direction. No edge can answer that question.

Content wider than its column does not sit inside it, and resolveTableFrame places an over-wide
table at a negative offset from its column whenever the table is right-aligned or centred โ€” and
end is the default justification for any bidiVisual table. So in an RTL section a wide table
starts left of its own column and ends past the separator, while never having left the later column:
both edges lie on the wrong side, and so does its origin. A negative w:ind does the same to a
paragraph, putting its origin in the gutter.

Measured on the repo's own fixture geometry (816px page, 96px margins, gap 48, separator at 408): a
wide right-aligned RTL table alone in the later column lands at x = -116, width = 500, and the
trailing-edge test suppressed a line Word draws. The earlier left-edge form had the mirror failure โ€”
a page-anchored watermark at x = 0 satisfied it and drew a line Word does not draw.

The fix stops guessing from coordinates and reads fragment.columnIndex, the engine's own record of
the owning column, documented as the field to trust "when overflow crosses margins".

Reworked across 2438bd9, db5947e and f2a36d8, after three review rounds and a QA pass found
four defects in it.
Two corrections to what earlier revisions of this description claimed:

The record is not the main path. columnIndex reaches only a few fragment kinds โ€” tables
(layout-table.ts, five sites), the three footnote body kinds in incrementalLayout.ts, and a
paragraph only when it is a collapsed split-line-break anchor carrier (layout-paragraph.ts,
under collapseSplitLineBreakCarrier, which comes from a purely document-driven predicate with no
flag behind it). An earlier revision said paragraphs never carry one; that is false, and the claim
was load-bearing, so it is corrected here and in both code comments that repeated it. The conclusion
survives โ€” a collapsed anchor carrier is a narrow shape, not the ordinary paragraph, so geometry
carries almost every fragment on the page.

Geometry needs four rules, not one. Containment of the origin and overlap are each wrong for a
case the other answers, because an indent and an over-wide box produce the same shape from opposite
causes. In order:

  1. Leading edge โ€” a box starting on a column's own edge is that column's, fit or no fit. That is
    ordinary content and content wider than its column, which overflows from that same edge.
    Overlap alone gets it wrong once the columns are unequal enough for the spill to cover more of
    the neighbour: widths: [100, 400], a 500px box at column 0's edge, 100px of its own column
    against 352px of the next.
  2. Trailing edge โ€” a different question, not a mirror. An indent moves only the leading edge, so
    a paragraph outdented further than the gutter has its origin inside the previous column while
    still ending exactly at its own column's trailing edge. Measured on equal 2-col geometry over
    624px (col0 [0,288), col1 [336,624)): a column-1 paragraph outdented 72px is the box
    [264, 624].
  3. The origin, fit-checked โ€” a smaller indent leaves both origin and box inside its own column.
    The fit is what rejects a shifted origin: a centred or end-justified over-wide table also
    begins inside an earlier column, and there the origin is no evidence at all.
  4. Overlap, for an origin hung into a gutter by a negative w:ind or a float offset.

Before all four, a width bound: anything at least as wide as the area it would have to be content
of belongs to no column, which is what keeps page-anchored objects out of the gate. That area has
two bounds and the threshold is the smaller. The strip's own span, since explicit widths are not
scaled to fill the page and an underfilling strip is narrower than the content area. And the page
content area, since those widths are not capped either โ€” nothing clamps their sum โ€” so
widths: [150, 600] with a 48px gap occupies 798px inside a 624px area, and against the strip bound
alone a page-wide graphic measures as merely partial and wins column 1 on overlap. Not
direction-specific: the LTR strip runs 0..150 / 198..798 and the mirrored RTL one
474..624 / -174..426, and the graphic wins column 1 in both.

Two more at the call site. A float is excluded by identity, not by size โ€” the width bound catches
a full-width watermark, but page.items is page.fragments.map(...) with no anchor filtering and an
anchored object carries its own measure.width, so a narrow float is the ordinary case: a 200px
logo at page x 500 on a 2-column page whose text never left column 0 had its origin inside column 1
and lit the gate. An out-of-range columnIndex is rejected rather than clamped โ€” clamping turned
columnIndex: 5 on a two-column page into 1, which is exactly the "a later column holds content"
this gate asks about, invented out of a number describing no column on the page.

columnOwningSpan and balanceSectionOnPage's ordinalOf independently arrived at the same four
rules in the same order, differing only at the end, where a sort key must name a column and the
painter may answer null. They should be one shared helper in contracts with a documented rule
order; that is not in this PR.

findColumnContaining remains in contracts as the strict counterpart to getColumnAtX, which
must clamp because a click has to select something. Its spans are half-open so that columns authored
with no gutter (w:space="0") do not both claim the boundary they share โ€” the boundary is exactly
where the later column's content begins, and an inclusive bound would hand it to the earlier column
in LTR but not in RTL, making the two directions disagree.

Backward compatibility

Column geometry and hit testing are byte-identical absent direction. Verified by running the pre-change column-layout.ts and this one side by side over 46,080 comparisons โ€” 960 LTR configurations (1-4 columns; gaps 0/24/48/720; valid, invalid and surplus explicit widths; all three equalWidth states; separators; non-uniform per-column gaps) across 6 content widths from 0 to 5000. Zero differences. The one exception is the gaps clamp above, which differs only for a negative gutter -- something OOXML cannot express and only a hand-built layout can supply.

The separator gate is not neutral, and deliberately so. An earlier revision of this description said every path was unchanged for LTR; that is wrong, and worth stating plainly rather than leaving a reviewer to find it. The gate was wrong in both directions, so fixing it changes LTR output in three ways: a later column holding only an outdented paragraph now draws the rule Word draws and previously drew none; an anchored float no longer lights the gate for a column whose text never arrived; and an out-of-range recorded columnIndex no longer resolves to the last column. Each is a case the old edge test got wrong going right as well as going left, which is why the regression tests cover both directions.

Everything else on this branch is either behind a direction !== 'rtl' early return, or reduces to the previous expression when direction is absent: the balancing comparator is (a, b) => a - b for LTR, character-for-character the old sort, and the footnote-band helpers are the identity when w15:footnoteColumns is absent.

Tests

contracts 539 ยท layout-engine (bun) 968 ยท layout-bridge 1792 ยท painters/dom 1551. tsc, vp fmt --check and vp lint clean.

One failure in packages/layout-engine/tests, architecture-boundaries Guard E, flags paint-time DOM measurement in painters/dom/src/ruler/ruler-renderer.ts โ€” an unmodified file that fails identically on a clean checkout of this base. Three scale tests (incrementalLayout.affected-frontier, incrementalLayout.checkpointMapEquality, persistent-page-surface) and one timing budget (incrementalLayout.semanticFlow) exceed their limits under worker contention on this machine and pass in isolation.

New coverage includes underfilling and overfilling explicit strips, separator mirroring onto the same physical gutter, hit testing at both edges and in the gutter, three columns with non-uniform per-column gaps, determineColumn under RTL (which had no coverage at all), and direction surviving clone/normalize/resolve and both equality checks.

For the separator gate specifically: a wide right-aligned RTL table whose x is outside its own
column, the same table belonging to the first column in both directions, a paragraph nudged into
the gutter by a negative indent, and a fragment on a zero-gap column boundary. Each was verified by
mutation โ€” reverting the ownership lookup fails three of them, and restoring the inclusive span
bound fails the zero-gap one.

One open question is now settled. Whether Word treats the first <w:col> as the column you
start in (the rightmost, under RTL) or as the leftmost one visually decides whether unequal
authored widths need reversing in an RTL section. Tested against Word with a two-section file โ€” an
RTL section and an LTR control, each w:num="2" w:equalWidth="0" with a narrow first column โ€” the
narrow column renders on the right in the RTL section and on the left in the LTR control. The
first <w:col> is fill order, which is what this PR assumes, so no width reversal is needed.

On the "nothing sets direction" review finding

Correct, and worth stating precisely. tests/src/test-helpers/to-flow-blocks.ts now reads w:sectPr/w:bidi (ST_OnOff, so a bare element means on, applied after the element loop so sibling order with w:cols does not matter) and sets columns.direction. That makes section-breaks-rtl-columns.test.ts a real end-to-end check: paragraph 1 moves from x=72 to x=330 on a Letter page, and neutering the mirror fails it.

That is a test adapter, and it does not fix real documents. The production PM/OOXMLโ†’FlowBlock adapter lives in @superdoc/docx-engine, which is a plain npm dependency here โ€” there is no sectPr parsing anywhere under packages/superdoc/src. SectionDirectionContext.pageDirection still has zero readers and zero writers.

So this branch is the complete engine half, proven end to end through an adapter that doubles as an exact reference for what the external one must do. Reaching users needs one more assignment wherever sectPr is projected onto SectionBreakBlock.columns, alongside the existing w:cols handling. Happy to write it if you point me at the seam, or to take a patch.

Word behaviour was checked against the OOXML spec and against Word's rendering of the same document; there is no pixel-diff gate to attach.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA โœ๏ธ โœ…
Posted by the CLA Assistant Lite bot.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/layout-engine/layout-engine/src/column-balancing.ts
@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

๐Ÿž Bugs (1) ๐Ÿ“˜ Rule violations (1) ๐Ÿ“œ Skill insights (0)



๐Ÿ”ด High

1. No producer wires w:bidi into ColumnLayout.direction ๐Ÿž
Description
The consumer-side RTL geometry pipeline only activates when ColumnLayout.direction is explicitly
set, but no production path bridges the existing section-level pageDirection/w:sectPr/w:bidi
signal into SectionBreakBlock.columns.direction; only hand-built tests set direction: 'rtl'.
Consequently, real imported Hebrew or Arabic multi-column documents retain an undefined direction
that defaults to LTR, so the first column remains on the left and the reported selection bug (#3952)
is not fixed for actual users.
Code

packages/layout-engine/contracts/src/index.ts[R2890-2903]

+  /**
+   * Section page direction, from `w:sectPr/w:bidi`. Decides which side the FIRST column sits on:
+   * `'ltr'` (default) fills left to right, `'rtl'` fills right to left, matching Word.
+   *
+   * Per ECMA-376 ยง17.6.1 a section's `w:bidi` governs section-level chrome โ€” page numbers, gutters
+   * and columns โ€” and is independent of the paragraph inline direction (ยง17.3.1.6). It is carried
+   * here, on the column layout itself, because `getColumnGeometry` is the single source every
+   * column consumer reads for positioning (fill, hit testing, separators, balancing, floating
+   * anchors, footnotes); threading the axis alongside the widths keeps those consumers from having
+   * to re-derive it, and keeps them from disagreeing.
+   *
+   * Absent means `'ltr'`. Every existing producer therefore keeps its current geometry unchanged.
+   */
+  direction?: BaseDirection;
Evidence
The section contract defines SectionDirectionContext.pageDirection as the resolved
w:sectPr/w:bidi value, but section processing only clones block.columns, and normalization
preserves input.direction only when it is already present. SectionsAdapter.setSectionDirection
is only a document-API interface with no production implementation found in the repository, while
the only identified OOXML section parserโ€”test helper readSectPrโ€”parses <w:cols> into `{count,
gap} without reading <w:bidi>` or assigning a direction; no production DOCX-import or PM-adapter
path was found that performs this bridge. All direction: 'rtl' column layouts in the diff are
manually created by tests, confirming that the new geometry is tested only with hand-built fixtures
rather than through the real document-to-flow-block pipeline.

packages/layout-engine/contracts/src/index.ts[2890-2903]
packages/layout-engine/contracts/src/column-layout.ts[156-175]
packages/layout-engine/contracts/src/direction-context.ts[40-52]
packages/layout-engine/contracts/src/index.ts[1994-2036]
packages/layout-engine/layout-engine/src/section-props.ts[25-28]
packages/layout-engine/contracts/src/column-layout.ts[228-237]
packages/layout-engine/contracts/src/column-layout.ts[156-170]
packages/layout-engine/contracts/src/column-layout.test.ts[413-421]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description

RTL column mirroring and its related geometry behavior are opt-in through `ColumnLayout.direction`, but the production import/conversion pipeline never assigns the resolved section bidi value to that field. Real sections carrying `w:sectPr/w:bidi` therefore reach layout with an undefined direction, which defaults to LTR, so the fix for #3952 works only for manually constructed test data rather than actual Hebrew or Arabic documents.

## Issue Context

`SectionDirectionContext.pageDirection` is the existing section-level signal documented as being resolved from `w:sectPr/w:bidi` and feeding section chrome such as columns. However, section layout snapshots and scheduling only carry `SectionBreakBlock.columns`; section processing clones that object, and normalization copies `input.direction` only if it was already populated.

`SectionsAdapter.setSectionDirection` is declared as a document-API interface, but no production implementation was found in this repository. The only identified OOXML section-properties parser, the test helper `readSectPr`, reads `<w:cols>` into `{count, gap}` but does not read `<w:bidi>` or set the column direction, and the documented resolver chain under `pm-adapter/src/direction/` was not found. Existing RTL column tests manually supply `direction: 'rtl'`, so they do not validate the document-to-flow-block path.

Wire the resolved section page direction into `SectionBreakBlock.columns.direction` when section-break and column metadata are produced, preserve it through section snapshots and normalization, and add an end-to-end test that begins with a section carrying `w:sectPr/w:bidi` rather than a hand-built `ColumnLayout`. If the production OOXML wiring exists elsewhere, link it explicitly in the PR description and cover it with an integration test; otherwise, treat the import-pipeline change as required before the feature reaches users.

## Fix Focus Areas

- packages/layout-engine/contracts/src/direction-context.ts[40-52]
- packages/layout-engine/contracts/src/index.ts[1994-2036]
- packages/layout-engine/contracts/src/index.ts[2890-2903]
- packages/layout-engine/layout-engine/src/section-props.ts[25-28]
- packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts[147-188]

โ“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



View medium (1)
๐ŸŸ  **Medium**
2. ColumnLayout.direction lacks consumer fixture ๐Ÿ“˜
Description
The new direction field is publicly reachable through the root-exported Layout.columns type, but
no fixture under tests/consumer-typecheck/src/ exercises it from the built superdoc package.
Existing Layout coverage only checks that the type is not any, so changes to this field can
escape consumer contract checks.
Code

packages/layout-engine/contracts/src/index.ts[2903]

+  direction?: BaseDirection;
Evidence
Rule 2900797 requires every expanded public API entry to have consumer usage against built package
types. superdoc publicly exports Layout, whose columns property is a ColumnLayout; the PR
adds direction to that nested public shape, while the consumer fixtures contain no ColumnLayout
reference or direction usage and only assert that Layout is not any.

Rule 2900797: Add consumer type-check fixtures for new public API surface
packages/layout-engine/contracts/src/index.ts[2901-2903]
packages/layout-engine/contracts/src/index.ts[3626-3632]
packages/superdoc/src/public/index.ts[118-121]
tests/consumer-typecheck/src/all-public-types.ts[357-362]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new public `ColumnLayout.direction` field is not exercised by a consumer type-check fixture.

## Issue Context
`Layout` is exported from the built `superdoc` entry point and exposes `ColumnLayout` through `Layout.columns`. Add a fixture under `tests/consumer-typecheck/src/` that imports `Layout` from `superdoc`, supplies both valid direction literals through the nested columns shape, and verifies the inferred field type so the fixture depends on the new API's input and output shape.

## Fix Focus Areas
- packages/layout-engine/contracts/src/index.ts[2901-2903]
- packages/superdoc/src/public/index.ts[120-120]
- tests/consumer-typecheck/src/all-public-types.ts[357-357]

โ“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Context sources
โœ… Compliance rules (platform): 1 rule
Review mode: ๐Ÿง  Deep: This is a behavior-changing RTL layout fix spanning contracts, geometry, hit testing, footnotes, balancing, and rendering, with many independent silent failure modes that benefit from redundant review.
โ“˜ย  1 issues published inline ยท 2 in summary โ€” for nitpicking, see all findings

Tip of the day
๐Ÿ’ก Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips โ†— | Customize Qodo โ†— | Qodo docs โ†—


Powered by Qodo

Comment on lines +2890 to +2903
/**
* Section page direction, from `w:sectPr/w:bidi`. Decides which side the FIRST column sits on:
* `'ltr'` (default) fills left to right, `'rtl'` fills right to left, matching Word.
*
* Per ECMA-376 ยง17.6.1 a section's `w:bidi` governs section-level chrome โ€” page numbers, gutters
* and columns โ€” and is independent of the paragraph inline direction (ยง17.3.1.6). It is carried
* here, on the column layout itself, because `getColumnGeometry` is the single source every
* column consumer reads for positioning (fill, hit testing, separators, balancing, floating
* anchors, footnotes); threading the axis alongside the widths keeps those consumers from having
* to re-derive it, and keeps them from disagreeing.
*
* Absent means `'ltr'`. Every existing producer therefore keeps its current geometry unchanged.
*/
direction?: BaseDirection;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ”ด High

4. No producer wires w:bidi into columnlayout.direction ๐Ÿž Bug โ‰ก Correctness

The consumer-side RTL geometry pipeline only activates when ColumnLayout.direction is explicitly
set, but no production path bridges the existing section-level pageDirection/w:sectPr/w:bidi
signal into SectionBreakBlock.columns.direction; only hand-built tests set direction: 'rtl'.
Consequently, real imported Hebrew or Arabic multi-column documents retain an undefined direction
that defaults to LTR, so the first column remains on the left and the reported selection bug (#3952)
is not fixed for actual users.
Agent Prompt
## Issue description

RTL column mirroring and its related geometry behavior are opt-in through `ColumnLayout.direction`, but the production import/conversion pipeline never assigns the resolved section bidi value to that field. Real sections carrying `w:sectPr/w:bidi` therefore reach layout with an undefined direction, which defaults to LTR, so the fix for #3952 works only for manually constructed test data rather than actual Hebrew or Arabic documents.

## Issue Context

`SectionDirectionContext.pageDirection` is the existing section-level signal documented as being resolved from `w:sectPr/w:bidi` and feeding section chrome such as columns. However, section layout snapshots and scheduling only carry `SectionBreakBlock.columns`; section processing clones that object, and normalization copies `input.direction` only if it was already populated.

`SectionsAdapter.setSectionDirection` is declared as a document-API interface, but no production implementation was found in this repository. The only identified OOXML section-properties parser, the test helper `readSectPr`, reads `<w:cols>` into `{count, gap}` but does not read `<w:bidi>` or set the column direction, and the documented resolver chain under `pm-adapter/src/direction/` was not found. Existing RTL column tests manually supply `direction: 'rtl'`, so they do not validate the document-to-flow-block path.

Wire the resolved section page direction into `SectionBreakBlock.columns.direction` when section-break and column metadata are produced, preserve it through section snapshots and normalization, and add an end-to-end test that begins with a section carrying `w:sectPr/w:bidi` rather than a hand-built `ColumnLayout`. If the production OOXML wiring exists elsewhere, link it explicitly in the PR description and cover it with an integration test; otherwise, treat the import-pipeline change as required before the feature reaches users.

## Fix Focus Areas

- packages/layout-engine/contracts/src/direction-context.ts[40-52]
- packages/layout-engine/contracts/src/index.ts[1994-2036]
- packages/layout-engine/contracts/src/index.ts[2890-2903]
- packages/layout-engine/layout-engine/src/section-props.ts[25-28]
- packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts[147-188]

โ“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified this today, and it still holds on the current tip โ€” recording the evidence here because the earlier answer went into a top-level comment, which left this thread looking unanswered.

grep -rln "columns.*direction\s*[:=]" packages/ outside tests matches exactly one file: packages/layout-engine/contracts/src/column-layout.ts, which is where the field is defined and read. No importer, adapter, or section-processing path assigns the resolved section bidi to it. So the finding is right as stated: this PR is the axis, not the end-to-end fix, and a real Hebrew or Arabic multi-column document still renders unchanged after it merges.

That is deliberate โ€” main has no column direction at all, so the axis has to exist before anything can feed it โ€” but it has a consequence worth flagging before merge rather than after:

This PR says Fixes #3952, so merging it will auto-close #3952 while the reported behaviour is still present for users. That is the wrong outcome for the issue tracker whichever way the scoping goes. Two ways out, and I am happy with either:

  1. I drop the Fixes keyword to a plain reference, and Columns ignore the section's w:bidi: the first column renders on the left in RTL documentsย #3952 stays open until a producer lands.
  2. I add the producer wiring to this PR, so Fixes is accurate.

Say which you prefer and I will do it. Leaving this thread open until then, since it is a live decision and not a closed loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went looking for whether anything else already wires this up, since if a producer were in flight the scoping question would answer itself. It is not, and the reason turns out to be structural rather than an oversight in this PR.

What I checked:

  • All 40+ open PRs on the repo. The only direction-related one is fix(direction): auto-detect paragraph base direction via dir="auto"ย #3714, fix(direction): auto-detect paragraph base direction via dir="auto" โ€” paragraph-level inline direction, touching rtl-styles.ts, render-line.ts and ParagraphNodeView.js. Different axis entirely; ECMA-376 keeps ยง17.3.1.6 paragraph w:bidi and ยง17.6.1 section w:bidi separate, and that PR is the former.
  • Merged PRs. Nothing matching bidi, direction, rtl, column or sectPr.
  • My own unmerged branches, in case I had it half-built: all four are page-number and list-numbering work in document-api/src/sections/. None touch section direction.

The structural part, which is the actual answer to "why has nobody done this":

SectionDirectionContext.pageDirection โ€” the field this PR's description cites as already governing section chrome โ€” has no producer either. grep -rn "pageDirection" over packages/ and shared/ returns three hits: the field declaration in direction-context.ts:47 and two generated .d.ts copies. Nothing constructs a SectionDirectionContext anywhere; grep -rn "SectionDirectionContext" outside tests and dist gives only the type definition and its re-export from index.ts.

And no importer parses w:sectPr/w:bidi at all. Every bidi hit in production code is paragraph-level or run-level (document-api's LANG_ALLOWED_KEYS, MARK_RUN_LANG_KEYS, and the Set paragraph base direction operation). Section-level w:bidi appears only inside comments in the contracts package, describing behaviour that was specified and never implemented.

So the section-direction contract is a declared-but-unbuilt surface that predates this PR. I wrote a consumer against the half that exists. That does not change the conclusion you drew โ€” this PR alone still does not move a real document โ€” but it does mean the missing producer is a gap in the contract rather than something this PR skipped, and wiring it needs an importer change that is a different subject from column geometry.

Which makes me lean toward option 1 from my previous comment: drop Fixes #3952 to a plain reference here, and let the issue close when a producer lands. Happy to go the other way if you would rather see it done in one piece โ€” still your call, and I have not changed the keyword yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with option 1 rather than leave this hanging on your reply โ€” the keyword is now dropped.

Fixes #3952 in the description is a plain "Part of #3952" reference, with the reason stated inline, so merging this no longer auto-closes a live issue. I also left a status note on #3952 itself recording what has landed and what is missing, so it does not get re-reported or closed by someone reading the merge.

Reversible in one edit if you would rather have the producer in this PR after all. My reasoning for not bundling it: reading w:sectPr/w:bidi and setting SectionBreakBlock.columns.direction is an importer change, a different subsystem from column geometry, and it needs an RTL multi-column fixture โ€” which under tests/README.md's privacy rules has to be synthetic and verifiable. That is a reviewable unit on its own and a poor fit stapled onto a PR already carrying two others.

Offered on #3952 to do it as a follow-up once this lands, if you want it from me.

@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

A section carrying `w:sectPr/w:bidi` fills its columns left to right, so the
first paragraph of a Hebrew two-column section lands in the LEFT column. Word
puts it on the right (ECMA-376 ยง17.6.1), and `SectionDirectionContext` already
documents `pageDirection` as governing columns -- but no function in the column
geometry ever received a direction.

`ColumnLayout` now carries an optional `direction`, and `buildColumnGeometry`
mirrors the strip about the CONTENT AREA when it is `'rtl'`. Indices stay in
fill order, so every consumer that walks columns 0..n-1 keeps filling in
document order and only the painted x changes; fill, hit testing, separators,
balancing, floating anchors and footnotes all follow from that single source.
The mirror axis is the content area and not the strip's own span because
explicit widths are not scaled to fill it -- a strip that underfills must end
up against the right margin with the slack on the left.

Four consumers needed direction awareness of their own, and each failed
silently without it:

- `getColumnAtX` walked the geometry assuming x ascends with the index.
- `toBalancingColumns` rebuilt the layout field by field and dropped the axis,
  so the balanced last page of an RTL section laid out left to right while
  every earlier page of the same section laid out right to left.
- Footnote column attribution broke on the first match under the same
  ascending assumption, collapsing a page's notes into column 0: the left
  column's notes printed under the right column and its own note area stayed
  empty.
- The DOM painter's separator gate read "content past the separator" as
  "content to the right", so a section whose content never left the first
  column drew a line Word does not draw.

Absent `direction`, every path is byte-identical to before: verified across
46,080 comparisons of geometry and hit testing over 960 LTR configurations
and 6 content widths.
โ€ฆs end to end

Follow-up to the RTL column-order fix in this branch, addressing review
feedback on superdoc#3953.

`balanceSectionOnPage` reconstructed document order by sorting the page's
fragments on ASCENDING x, on the premise that the paginator fills column 0
first. That premise inverts under this branch: in an RTL section column 0 is
the RIGHT column, so document order DESCENDS in x. The balancer consumed the
trailing column first and wrote the balanced x/y back in that order, which
scrambles the reading order of a balanced page rather than merely mirroring
it. Measured on a 2-column RTL page of 6 paragraphs: x came back as
[432, 96, 96, 96, 432, 432] instead of [432, 432, 432, 96, 96, 96]. The sort
is now direction-relative. The existing RTL balancing test could not catch
this because its fixture places every fragment at the same x, which makes the
ascending sort a stable no-op.

Two smaller geometry corrections:

- A single column is mirrored too. The old guard skipped `count < 2`, so an
  explicit one-column section that underfills the content area stayed pinned
  to the LEFT margin, contradicting the axis rule the multi-column path
  applies. It is a provable no-op whenever the column fills the area, so
  equal-mode `count: 1` is byte-identical.
- Per-column `gaps` are clamped to >= 0, matching the scalar `gap` above.
  OOXML cannot express a negative gutter (`w:space` is unsigned), but a
  hand-built layout could, and a gap negative enough to pull a column behind
  its predecessor would make an upright LTR strip answer hit tests as if it
  were mirrored.

`ColumnLayoutForAnchor` and `ParagraphAnchorsContext.columns` now declare
`direction` and `contentWidth`. Runtime was already correct because every
caller passes a full normalized layout, but neither would have produced a type
error if a future edit dropped the fields -- the exact failure mode that made
the `toBalancingColumns` fix necessary.

Coverage. Three paths in the previous commit survived mutation:
`toBalancingColumns` dropping both spreads, the footnote column boundary
reverted to its LTR-only form, and `determineColumn` in position-hit, which
had no RTL coverage at all. Each now has a test that fails without its fix,
and position-hit also covers three columns, which nothing exercised before.

`tests/src/test-helpers/to-flow-blocks.ts` reads `w:sectPr/w:bidi` (ST_OnOff,
so a bare element means on) and sets `columns.direction`, which makes
`section-breaks-rtl-columns.test.ts` an end-to-end check from OOXML section
properties down to fragment x. This is a TEST adapter and does not reach real
documents: the production PM/OOXML adapter is not in this repository. It does
double as a precise reference for what that adapter must do.

Adds a consumer-typecheck fixture for the new public `ColumnLayout.direction`,
reachable from outside the package through `Layout.columns`.
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Thanks โ€” both findings were right, and both are addressed in 99cc234.

High โ€” no producer wires w:bidi into ColumnLayout.direction. Accurate, and the PR description now says so directly instead of burying it. Two things changed:

  • tests/src/test-helpers/to-flow-blocks.ts now reads w:sectPr/w:bidi (ST_OnOff, so a bare element means on; applied after the element loop so sibling order with w:cols does not matter) and sets columns.direction. That makes the new section-breaks-rtl-columns.test.ts a real end-to-end check from section properties down to fragment x โ€” paragraph 1 moves from x=72 to x=330 on a Letter page, and neutering the mirror fails it.
  • The PR is explicit that this is a test adapter and does not reach real documents. The production PM/OOXMLโ†’FlowBlock adapter is in @superdoc/docx-engine, a plain npm dependency here โ€” there is no sectPr parsing anywhere under packages/superdoc/src, and SectionDirectionContext.pageDirection still has zero readers and zero writers.

So the branch is the complete engine half, proven end to end through an adapter that doubles as a reference for what the external one must do. I have asked the maintainers for the seam where sectPr is projected onto SectionBreakBlock.columns.

Medium โ€” no consumer fixture. Added tests/consumer-typecheck/src/layout-rtl-column-direction.ts, reaching the field the way a consumer does, through Layout['columns']: both literals assignable, the field optional so a direction-unaware consumer still compiles, the read-back union not widened to string, and a value read from a layout assignable back into one.

Your review also prompted a re-audit that found something neither of us had flagged: balanceSectionOnPage reconstructed document order by sorting fragments on ascending x, which inverts under this change because column 0 is the rightmost in RTL. The balancer consumed the trailing column first and wrote the balanced x/y back in that order, scrambling a balanced page's reading order rather than mirroring it โ€” measured [432, 96, 96, 96, 432, 432] instead of [432, 432, 432, 96, 96, 96]. Fixed with a direction-relative comparator, plus a guard test that fails without it.

Three more paths that survived mutation now have tests that do not: toBalancingColumns dropping its spreads, the footnote column boundary reverted to its LTR-only form, and determineColumn under RTL, which had no coverage at all.

โ€ฆtrails

Review follow-up on superdoc#3953. The RTL branch of the separator gate tested the
fragment's LEFT edge, the same edge the LTR branch tests, which leaves the two
asymmetric for anything wider than a column.

`page.items` carries anchored drawings alongside column content, so a
page-relative watermark or logo sits at `x = 0` spanning the page. Going right
it is never past the separator; going left, a left-edge test always puts it
past. An RTL section with `w:sep="1"` whose text all fits in the first column
therefore drew a separator on the strength of the watermark alone -- a line
Word does not draw, which is exactly what this gate exists to prevent.

Each branch now tests the edge that trails in its own fill direction: the left
edge going right, the right edge going left.
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Third round of review follow-up โ€” one more real defect, in code this PR added.

A QA pass over the combined change found that the RTL branch of the separator gate tested the fragment's left edge, the same edge the LTR branch tests. That leaves the two branches asymmetric for anything wider than a column, and page.items carries anchored drawings alongside column content: a page-relative watermark or logo sits at x = 0 spanning the page. Going right it is never past the separator; going left, a left-edge test always puts it past.

So an RTL section with w:sep="1" whose text all fits in the first column drew a separator on the strength of the watermark alone โ€” precisely the line this gate exists to suppress. Each branch now tests the edge that trails in its own fill direction: the left edge going right, the right edge going left. Regression test added and mutation-checked (it fails with the left-edge form restored).

The same pass verified the rest numerically against the source and found nothing else:

  • Mirror geometry โ€” single column in equal mode is an exact no-op; a narrow explicit single column right-aligns as intended; contentWidth = 0 falls to the degenerate branch cleanly; overfull widths mirror the LTR overflow exactly (x = 202, -246 against 0, 448); count greater than the supplied widths is clamped before geometry.
  • getColumnAtX โ€” the monotonicity inference cannot be fooled once gaps are clamped: geometry[0].x is always 0 and geometry[1].x = widths[0] + gapAfter[0] with both terms non-negative. Checked at 1px widths, at shared boundaries, and in the gutter of a 3-column RTL layout.
  • Balancing comparator โ€” verified at 3 columns: [528,528,528,312,312,312,96,96] against [96,96,96,312,312,312,528,528] in LTR, y identical. No path reaches a mirrored geometry with the direction missing; both call sites go through the single toBalancingColumns builder.
  • Footnote attribution โ€” replicated the loop against real 3-column geometry: an exact mirror of LTR at column origins, column midpoints, gutter midpoints, ยฑ1px around every boundary, and out of range on both sides.
  • Field preservation โ€” every construction site checked, including snapshotColumns, the checkpoint spreads, resolvePageColumns and the preserved footnote seed. page.columns and columnRegions both go through resolveColumnLayout โ†’ cloneColumnLayout, so the painter always sees the field.
  • Backward compatibility โ€” contentWidth is inert in LTR because buildColumnGeometry returns before touching it; mirrored computes false for every LTR layout; both equality helpers normalise undefined โ†’ 'ltr', so no spurious region splits or cache invalidations.

contracts 504 ยท layout-engine 966 ยท layout-bridge 1884 ยท painters/dom 1547. Rebased onto 2.11.1-next.1.

One pre-existing issue surfaced that is not part of this PR and is direction-independent, noted only so it is not lost: normalizeColumnLayout pairs gaps positionally against the raw widths array while resolveColumnLayout re-pairs them after filtering unusable widths. For {count: 3, equalWidth: false, widths: [0, 200, 150], gaps: [10, 40]} the two disagree โ€” gaps: [10] against gaps: [40], giving x = 210 against x = 240 in LTR. Happy to open a separate issue if that is useful.

โ€ฆ not by an edge

The column-separator gate asks whether a LATER column holds content, because
Word draws no line next to an empty column. It answered by comparing a fragment
edge against the separator x, choosing whichever edge trails in the fill
direction. No edge can answer that question.

Content wider than its column does not sit inside it, and `resolveTableFrame`
places an over-wide table at a NEGATIVE offset from its column whenever the
table is right-aligned or centred -- and `end` is the default justification for
any bidiVisual table. So in an RTL section a wide table starts left of its own
column and ends past the separator, while never having left the later column:
both of its edges lie on the wrong side, and so does its origin. A negative
`w:ind` puts a paragraph's origin in the gutter with the same effect.

Use `fragment.columnIndex` instead -- the engine's own record of the owning
column, written for paragraphs and tables as they are laid out, and documented
as the field to trust "when overflow crosses margins". Geometry is the fallback
for a fragment carrying no such record, and it is containment rather than
`getColumnAtX` because containment can answer "no column": that is what keeps
page-anchored objects out of the gate, a full-width watermark belonging to none.

`findColumnContaining` is the new contracts helper for that fallback, the strict
counterpart to `getColumnAtX`, which must clamp because a click has to select
something. Its spans are half-open so that columns authored with no gutter do
not both claim the boundary they share -- the boundary is exactly where the
later column's content begins, and an inclusive bound would give it to the
earlier column in LTR but not in RTL, making the two directions disagree.

The painter's private separator helper now returns the geometry rather than bare
x positions, so each separator stays paired with the column it follows instead
of relying on array-index alignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/layout-engine/painters/dom/src/renderer.ts Outdated
โ€ฆts origin

Review follow-up on superdoc#3953. The separator gate reads `fragment.columnIndex`
first and falls back to geometry, but the fallback tested containment of the
fragment's ORIGIN, and the previous commit's rationale assumed the engine
records `columnIndex` for paragraphs. It does not: the paginator writes it for
tables (layout-table.ts) and for footnote bodies, and nowhere for an ordinary
paragraph fragment. Paragraphs therefore always reach the fallback.

That matters because a paragraph's origin can sit outside its own column. A
negative `w:ind` hangs it into the gutter, and containment then answers "no
column" -- so a later column holding only an outdented paragraph registered as
empty and its separator was suppressed, a line Word draws. The same shape
applies to an over-wide right-aligned or centred table, which
`resolveTableFrame` places at a negative offset from its column.

Attribution is now by overlap: the column whose span the fragment covers most,
ties going to the earliest in fill order. Anything at least as wide as the whole
content area still belongs to no column, which is what keeps page-anchored
objects out of the gate -- a full-width watermark overlaps every column without
being content of any, and counting it would draw a separator on a page whose
text never left the first column.

Both directions are covered: an outdented paragraph alone in a later column now
draws its separator, and the watermark case still does not.
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Valid, and it went further than the report โ€” thanks. Fixed in 2438bd9.

You were right that production paragraph fragments do not carry columnIndex. I checked every write of it under packages/layout-engine outside tests: layout-table.ts writes it at five sites, footnote bodies carry it, and layout-paragraph.ts:886 sets it on an anchored table placement record rather than on a paragraph fragment. There is no path that puts it on an ordinary paragraph. The previous commit's rationale claimed otherwise and the PR body repeated the claim; both are corrected.

So paragraphs always take the fallback, which means it has to be right on its own rather than as a rare backstop. Containment of the origin is not right: a negative w:ind hangs a paragraph into the gutter, its origin lands outside its own column, and a later column holding only that paragraph registered as empty โ€” suppressing a separator Word draws. That is your scenario, and it is now a regression test that fails against the containment form.

Rather than populating columnIndex on every fragment, which would touch the paginator broadly for a paint-time question, attribution is now by overlap: the column whose span the fragment covers most, ties going to the earliest in fill order. It handles both shapes you named โ€” outdent and overflow โ€” without needing the record.

The one property the fallback still has to keep is the ability to answer "no column", since that is what excludes page-anchored objects: a full-width watermark overlaps every column without being content of any, and counting it would draw a separator on a page whose text never left the first column. So anything at least as wide as the content area is attributed to no column. That rule also catches an over-wide table reaching the fallback without a recorded column, where null is the conservative answer โ€” it can only ever suppress a separator, never invent one.

Both directions are covered by tests now, and both were mutation-checked: the outdented-paragraph case fails with the containment form restored, and the watermark case fails with the earlier left-edge form restored.

painters/dom 1558 passing, contracts 546. The one failure in that package is persistent-page-surface (1,003 pages, 5s budget), which exceeds its limit under worker contention and passes in isolation.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/layout-engine/painters/dom/src/renderer.ts Outdated
โ€ฆdges

Follow-up to cubic's review of 2438bd9, and to three defects a QA pass over the
same function found. All four are in code this PR added.

`columnOwningSpan` answers "which column owns this box", and the separator gate
asks it "does a LATER column hold content". A wrong answer that names a later
column INVENTS a rule Word does not draw; one that names an earlier column or
none SUPPRESSES a rule Word does draw. Both were reachable.

**The width bound measured the strip, not the page.** Explicit widths are floored
to >= 1px but never CAPPED -- nothing clamps their sum -- so an authored
`w:num="2"` with two over-wide `w:col/@w` produces a strip WIDER than the content
area. Against the strip's own span a page-wide graphic then measures as merely
partial, and overlap attribution hands it to whichever column it covers most:
`widths: [150, 600]` on a 624px area gives it 150px of column 0 against 426px of
column 1. The threshold is now the smaller of the two bounds. Not RTL-specific,
which the report had it as: the LTR strip runs 0..150 / 198..798 and the mirrored
RTL one 474..624 / -174..426, and the graphic wins column 1 in both.

**Neither edge test existed.** Attribution was overlap after a containment test,
and both are wrong for a case the other answers, because an indent and an
over-wide box produce the same shape from opposite causes:

  - A box on a column's LEADING edge is that column's, fit or no fit -- ordinary
    content, and content wider than its column, which overflows from that edge.
    Overlap alone gets it wrong once the columns are unequal enough for the spill
    to cover more of the neighbour: `widths: [100, 400]`, a 500px box at column
    0's edge, 100px of its own column against 352px of the next.
  - A box on a column's TRAILING edge is that column's too, and that is a
    different question rather than a mirror. An indent moves only the leading
    edge, so a paragraph outdented FURTHER than the gutter has its origin inside
    the previous column while still ending exactly at its own column's trailing
    edge -- and containment then read its column as empty. Measured on equal
    2-col geometry over 624px (col0 [0,288), col1 [336,624)): a column-1
    paragraph outdented 72px is the box [264, 624].

Containment survives as the third rule, now fit-checked, and overlap as the
fourth. `balanceSectionOnPage`'s `ordinalOf` reached the same four rules in the
same order for the same reasons; the two differ only at the end, where a sort key
must name a column and this may answer `null`. They should be one shared helper
in `contracts`, and are not yet.

**Folded the strip bounds out of `Math.min(...map)`.** `w:num` is bounded at 45 by
the schema but nothing in the pipeline enforces it, and a host-built layout with a
six-figure count overflowed the argument stack -- a paint-time crash out of
`paint()`, taking the whole document with it, from a function whose only job is to
answer conservatively.

Two further fixes at the call site, from the same QA pass:

**A float is not column content, and no width threshold can recognise one.** The
threshold catches a full-width watermark, which is what it was written for, but
`page.items` is `page.fragments.map(...)` with no anchor filtering, and an
anchored object carries its own `measure.width` -- so a narrow one is the ordinary
case. A 200px logo at page x 500 on a 2-column page whose text never leaves column
0 has its origin inside column 1 and lit the gate. Excluded by identity
(`isAnchored`) instead. Every float, not only page-relative ones: `hRelativeFrom`
is consumed at layout time and never reaches the fragment, and there is no
evidence here about whether Word draws a rule beside a column holding a floating
object and no text. Word's rule tracks text, and the gate is deliberately
asymmetric, so the conservative reading is also the simpler one.

**An out-of-range `columnIndex` is rejected, not clamped.** Clamping turned any
stale or corrupt value into a real index -- `columnIndex: 5` on a two-column page
became 1 -- which is exactly the "a later column holds content" the gate asks
about, invented out of a number describing no column on the page. Falling through
to geometry answers from the fragment's actual position. Floored first, so float
drift on a valid index still resolves.

Eleven tests, each pinning one rule. The page-bound and both edge tests were
mutation-checked: restoring the old threshold or removing either edge rule fails
exactly one test each, and three different ones.
The separator gate's comment said the paginator writes `columnIndex` "for tables
and footnote bodies but not for ordinary paragraphs", and the last clause is
wrong. `layout-paragraph.ts` sets it on a `kind: 'para'` fragment when
`collapseSplitLineBreakCarrier` is on, and that comes from
`splitCarrierMode === 'spaced'` โ€” a purely document-driven predicate with no flag
behind it: a line-break-only paragraph, followed by an anchored drawing, followed
by a paragraph sharing its `sourceAnchor.sourceRef`, where the carrier has
positive spacing.

The claim was load-bearing. It says the record is absent for the kind that
dominates a page, so `columnOwningSpan` carries the work and has to be right
alone. That conclusion survives โ€” a collapsed anchor carrier is a narrow shape,
not the ordinary paragraph โ€” but "paragraphs never carry one" would have
justified deleting a rule the function needs, and a reader checking the premise
would have found a counterexample and distrusted the rest.

Listing the kinds instead of asserting a rule: tables at five sites in
`layout-table.ts`, the three footnote body kinds in `incrementalLayout.ts`, and
that one carrier paragraph.

Comment only; no behavior change.
`Core` fails on `vp fmt --check`, and this is the only file it flags on the
branch. Prettier's print width fits the four parameters on a single line at 118
characters; the multi-line form the earlier commit left there is the whole
difference. `CI V2 Public / validate` is the aggregate job and fails only
because `Core` did.

Formatting only; no behavior change.
โ€ฆt edge

The separator gate's origin-containment step was gated on the box's right edge
landing inside the column its origin is in. That gate has two problems, and they
point the same way.

It rejects a box that genuinely belongs to the column its origin is in.
`layout-paragraph.ts` re-points a paragraph carrying `attrs.floatAlignment` of
`right` or `center` at `columnX + (effectiveColumnWidth - maxLineWidth)` and
never reduces `fragment.width`. So a 50px line in a 288px column is recorded as
`x = columnX + 238` with `width` still 288: its origin is inside its own column
and its right edge overhangs by 238px. The edge gate rejected it, the overlap
vote then saw 50px of column 0 against 190px of column 1 and moved it, and a
page whose text never left column 0 drew a separator โ€” the same false positive as
the narrow page-anchored object, reached with no anchored object at all.

And an edge gate is dead code anyway. Pass it and the box lies wholly inside one
column's span; `getColumnGeometry` never emits overlapping spans, so every other
column's overlap is zero and the vote returns that same column regardless.
Swept over outdents from 0 to 160px in 2px steps, an edge-gated containment step
and plain overlap never disagreed once โ€” so the step was doing no work while
being the thing that broke the frame case.

Width is what actually separates the two shapes, because the right edge overhangs
in both. A box no wider than its column was placed in that column wherever its
origin ended up. A box WIDER than its column may instead have been pulled LEFT
out of it: a negative `w:ind` widens the fragment by the outdent, so an outdent
larger than the gutter lands the origin in the PREVIOUS column while the content
belongs to this one. Measured on equal 2-column geometry over a 624px content
area (col 0 [0,288), col 1 [336,624)): a column-1 paragraph outdented 72px is the
box [264, 624], origin in column 0, width 360 against a 288px column โ€” it does
not fit, the origin is distrusted, and overlap answers column 1 correctly.

Both shapes are now pinned, and the pair is the test: the frame keeps its own
column and draws no rule, the outdent falls through to overlap and draws one.
Replaces an earlier test whose fixture was a 100px box at the outdented origin,
which no layout path produces โ€” a negative `w:ind` widens the fragment, so a
narrow box at that origin is a fragment that really does start in column 0.

`painters/dom` is 61 files / 1565 pass.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts Outdated
cubic's review caught that the test standing for the width gate's rejection path
never reaches it, and the same mistake was written into the gate's own comment as
its justification.

An outdented paragraph cannot reach that step. A negative `w:ind` widens the
fragment by exactly the outdent it shifts by, so `x + width` lands on its own
column's trailing edge for EVERY outdent -- the trailing-edge rule answers first
and the gate never sees the box. On equal 2-col geometry over 624px (col0
[0,288), col1 [336,624)), a column-1 paragraph outdented 72px is [264, 624], and
624 IS column 1's trailing edge. Any other outdent lands there too.

That fixture was the only guard on the gate, so the gate had none. Measured
rather than assumed: replacing the width comparison with unconditional origin
trust left all 39 tests in this file passing.

The shape that does reach it is a centred over-wide box. `resolveTableFrame`
centres an over-wide table inside its column at
`col.x + (col.width - width) / 2`, a NEGATIVE offset once the table is wider than
the column, so it begins inside an earlier column without ever having left its
own -- and unlike the outdent, its right edge lands nowhere in particular. A
400px box centred in column 1 is [280, 680]: 680 misses column 1's 624 by 56, the
origin 280 falls inside column 0, and 400 does not fit a 288px column, so the
origin is rejected and overlap answers column 1, 288px against 8px. Under the
same mutation this fixture fails, and it is the only test that does.

Both comments now say what the mistake was rather than quietly swapping the
fixture: a reader who checks the old justification finds a counterexample and
has no way to tell how far the error spread.

Test and comments only; no behavior change.
โ€ฆs content

`getColumnAtX`'s mirrored branch tested an INCLUSIVE upper bound, so it disagreed
with the half-open spans that `findColumnContaining` and the geometry itself use.

With `w:space="0"` (ECMA-376 ยง17.6.3) adjacent columns share an edge, and in an RTL
section that shared edge is the earlier fill column's own left edge -- exactly where
its content is placed -- so the inclusive form handed it to the LATER column and
every column boundary in a zero-gutter RTL section resolved one column too far. Two
columns over 602px mirror to column 0 at [301,602) and column 1 at [0,301):
`findColumnContaining(301)` answered 0 and `getColumnAtX(301)` answered 1, so the two
resolvers disagreed at the one point they can be made to disagree about. The same
bound also claimed the point on a column's trailing edge, which is gutter and belongs
to the column preceding it in fill order.

`cx <` is correct on both counts and makes the two resolvers agree everywhere they
can both answer.

Fixed here rather than one PR up the stack, where it was first written. This branch
introduces the mirrored branch and its inclusive bound, so it is where the defect
enters the tree; leaving it for superdoc#3962 meant superdoc#3953 and superdoc#3961 would both merge with a
line already known to be wrong. Dormant in production either way -- nothing assigns
`ColumnLayout.direction` yet -- but the review record should not carry a known defect
across two merges when the fix is three lines.

The RTL case in position-hit.test.ts worked its geometry out as column 1 spanning
336..528; the mirrored geometry puts it at 312..504, a full gutter off. Corrected,
with the derivation spelled out, since that comment misleads a reader of this diff
today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Added ad9bde6: the RTL column-boundary fix, moved down from #3962.

getColumnAtX's mirrored branch tested an inclusive upper bound, disagreeing with the half-open spans findColumnContaining and the geometry itself use. With w:space="0" adjacent columns share an edge, and in RTL that edge is the earlier fill column's own left edge โ€” so the inclusive form handed it to the later column and every boundary in a zero-gutter RTL section resolved one column too far.

cubic found it on #3961 and I first fixed it there-and-then in #3962, which meant this PR and #3961 would both have merged carrying a line already known to be wrong. This branch introduces the bound, so this is where it belongs. Three lines plus the test that pins the zero-gutter boundary and the trailing-edge case in both resolvers.

Also corrects the RTL comment in position-hit.test.ts, which worked column 1 out as 336..528 where the mirrored geometry puts it at 312..504 โ€” a full gutter off, and misleading to anyone reading this diff.

Dormant in production either way, for the reason in the thread above: nothing assigns ColumnLayout.direction yet.

This is a plain push, not a force-push โ€” every existing review thread and comment on this PR is untouched. #3961 and #3962 are rebased onto it; #3962's tree is byte-identical to before the move, only the commit's placement changed.

Y-PLONI pushed a commit to Nathaniel-260/otzaria-word-editor that referenced this pull request Sep 2, 2026
โ€žืขืžื•ื“ื•ืช โ† ืฉืชื™ื™ืโ€ ื‘ืžืกืžืš ืขื‘ืจื™ ืžื™ื™ืฆืจืช ืงื•ื‘ืฅ ืชืงื™ืŸ ื•ืžืฆื™ื™ืจ ืื•ืชื• ื”ืคื•ืš. ื”ืžื ื•ืข ืžืžืœื
ืืช ื”ื˜ื•ืจื™ื ืฉืžืืœโ†’ื™ืžื™ืŸ ื’ื ื›ืฉื‘-`sectPr` ื™ืฉ `w:bidi`, ื‘ืขื•ื“ ECMA-376 ยง17.6.1 ืงื•ื‘ืข
ืฉื”ืขืžื•ื“ื” ื”ืจืืฉื•ื ื” ืฉื™ื™ื›ืช ืœืฆื“ ื™ืžื™ืŸ. ื”ื˜ื•ืจ ื”ื™ืžื ื™ ื”ื•ื ืœื›ืŸ ื”**ืฉื ื™** ื‘ืกื“ืจ ื”ืžืกืžืš,
ื•ื’ืจื™ืจื” ืฉืžืชื—ื™ืœื” ื‘ื• ื•ืžืžืฉื™ื›ื” ืฉืžืืœื” ื”ื•ืœื›ืช ืื—ื•ืจื”: ื”ื™ื ืžื›ื•ื•ืฆืช ืืช ื”ื‘ื—ื™ืจื” ื•ืžื•ื—ืงืช ืืช
ืžื” ืฉื›ื‘ืจ ืกื•ืžืŸ. ื–ื” ื”ื‘ืื’ ืฉื“ื•ื•ื—.

ืžื” ืฉื ืžื“ื“ ื‘-Chrome ืืžื™ืชื™ ืžื•ืœ ื”-dist ื”ืืจื•ื– โ€” `scripts/qa/column-selection-probe.mjs`,
ืฉื‘ืข ืฉื•ืจื•ืช, ืฉืœื•ืฉ ืžื”ืŸ ืขื•ื‘ืจื•ืช:

  - ื”ื™ื™ืฆื•ื **ื ื›ื•ืŸ**: `w:bidi` ื•ืœืฆื“ื• `<w:cols w:num="2" w:equalWidth="1"/>`.
    ื”ืงื•ื‘ืฅ ื ืคืชื— ื ื›ื•ืŸ ื‘-Word.
  - ืฉื•ืจื” 01 ื ื•ื—ืชืช ื‘ื˜ื•ืจ ื”ืฉืžืืœื™ (x=538, ืืžืฆืข ื”ื“ืฃ 700).
  - ื’ืจื™ืจื” ื‘ืกื“ืจ ื”ืžืกืžืš ืจืฆื™ืคื” ื•ื—ื•ืฆื” ืืช ื”ื’ื‘ื•ืœ; ื‘ืกื“ืจ ื”ืงืจื™ืื” ื”ื˜ื•ืจ ื”ื™ืžื ื™ ื”ื’ื™ืข
    ืœ-8 ืฉื•ืจื•ืช ืžืกื•ืžื ื•ืช ื•ื™ืจื“ ืœ-5.
  - `Shift+ื—ืฅ ืžื˜ื”` ืื™ื ื• ื—ื•ืฆื” ืืช ื”ื’ื‘ื•ืœ, ื•ืœื›ืŸ ืื™ื ื• ืžืขืงืฃ.

ืฉืœื•ืฉ ื”ืฉื•ืจื•ืช ืฉืขื•ื‘ื“ื•ืช ืื™ื ืŸ ืงื•ืกืžื˜ื™ืงื” ื‘ื“ื•ื—: ื‘ืœืขื“ื™ื”ืŸ ืื™ ืืคืฉืจ ืœื“ืขืช ืื โ€žื”ื‘ื—ื™ืจื”
ืฉื‘ื•ืจื”โ€ ืื• โ€žืกื“ืจ ื”ื˜ื•ืจื™ื ื”ืคื•ืšโ€, ื•ื–ื” ื”ื”ื‘ื“ืœ ืฉืงื•ื‘ืข ืื™ืคื” ื”ืชื™ืงื•ืŸ. ื”ื•ื ื‘ืžื ื•ืข ื”ืคืจื™ืกื”,
ื•ืœื ื›ืืŸ โ€” PR ืคืชื•ื— ื‘-superdoc/docx-editor#3953.

ืžื” ืฉื›ืŸ ืืคืฉืจ ื›ืืŸ ื”ื•ื ืœื ืœื”ืฉืชื™ืง. ื”ืคืขื•ืœื” ืื™ื ื” ื ื—ืกืžืช, ื›ื™ ื—ืกื™ืžื” ื”ื™ื™ืชื” ืžื•ื ืขืช
ืžื”ืžืฉืชืžืฉ ืœื™ื™ืฆืจ ืžืกืžืš ืชืงื™ืŸ ื‘ื’ืœืœ ื‘ืื’ ื‘ืฆื™ื•ืจ. ื‘ืžืงื•ื ื–ื” `CommandOutcome` ืžืงื‘ืœ ืขืจื•ืฅ
ืฉืœื™ืฉื™, `note`: ื”ืฆืœื—ื” ืฉื™ืฉ ืขืœื™ื” ืžื” ืœื•ืžืจ. ื‘ืœืขื“ื™ื• ื™ืฉ ืจืง โ€žืฉืงื˜โ€ ืื• โ€žื ื›ืฉืœื”โ€, ื•ืฉืชื™ื”ืŸ
ืฉืงืจ โ€” ื”ืคืขื•ืœื” ืœื ื ื›ืฉืœื”, ื•ืฉืชื™ืงื” ืžืฉืื™ืจื” ืืช ื”ืžืฉืชืžืฉ ืžื•ืœ ืชืฆื•ื’ื” ืฉื ืจืื™ืช ืฉื‘ื•ืจื” ื‘ืœื™
ื”ืกื‘ืจ.

`rtlColumnNote` ืžื›ืจื™ืขื” ืžืชื™ ืื•ืžืจื™ื: ืฉืชื™ ืขืžื•ื“ื•ืช ื•ืžืขืœื”, ื•ืœืคื—ื•ืช ืžืงื˜ืข ืื—ื“ ืฉื”ืžื ื•ืข
ืžื“ื•ื•ื— ืขืœื™ื• `sectionDirection: 'rtl'`. ื›ื™ื•ื•ืŸ ื—ืกืจ ืื™ื ื• ื ื—ืฉื‘ ืขื‘ืจื™ โ€” ื”ื•ื“ืขื” ืขืœ
ืžืกืžืš ืœื•ืขื–ื™ ื’ืจื•ืขื” ืžืฉืชื™ืงื” ืขืœ ืžืกืžืš ืขื‘ืจื™, ื›ื™ ื”ื™ื ืžืชืืจืช ืชืงืœื” ืฉืื™ืŸ.

ื”ื ื™ืงื•ื™ ื”ื•ื ื”ื—ืฆื™ ื”ืฉื ื™, ื•ื‘ืœืขื“ื™ื• ื”ื”ื•ื“ืขื” ื ืขืฉื™ืช ืฉืงืจ: โ€žืขืžื•ื“ื•ืช โ† ืื—ืชโ€ ืื—ืจื™
โ€žืขืžื•ื“ื•ืช โ† ืฉืชื™ื™ืโ€ ื”ื™ื™ืชื” ืžืฉืื™ืจื” ืขืœ ื”ืคืก ืชื™ืื•ืจ ืฉืœ ืกื“ืจ ื˜ื•ืจื™ื ื‘ืžืกืžืš ืฉืื™ืŸ ื‘ื• ื˜ื•ืจื™ื.
ืœื›ืŸ ื”ืฆืœื—ื” ื‘ืœื™ ื”ื•ื“ืขื” ืžื ืงื” ืื•ืชื” โ€” ืื‘ืœ ืจืง ืื ื”ื™ื ืขื“ื™ื™ืŸ ื–ื• ืฉืขืœ ื”ืžืกืš, ืื—ืจืช ื›ืœ
ืคืงื•ื“ื” ืžื•ืฆืœื—ืช ื”ื™ื™ืชื” ืžื•ื—ืงืช ื”ื•ื“ืขื” ืฉืœ `STATUS_NOTIFIER`, ื”ื›ื•ืชื‘ ื”ืฉื ื™ ืœืื•ืชื• ืคืก.

ืื•ืจืš ื”ื”ื•ื“ืขื” ื”ื•ื 75 ืชื•ื•ื™ื, ื•ืœื ืœืคื™ ื˜ืขื: `.status-item` ื”ื•ื `white-space: nowrap`
ื‘ืœื™ `overflow`/`text-overflow`, ื•ืœื›ืŸ ื”ื•ื“ืขื” ืืจื•ื›ื” ืžื“ื™ ื“ื•ื—ืคืช ืืช ื”ืคืก ื•ืื™ื ื”
ืžืชืงืฆืจืช. 78 ื”ื•ื ืื•ืจืš ื”ื”ื•ื“ืขื” ื”ืืจื•ื›ื” ื‘ื™ื•ืชืจ ืฉื”ืคืก ื›ื‘ืจ ื ื•ืฉื.

ื”ื‘ื“ื™ืงื” ื”ืงื™ื™ืžืช `applyColumns` ืฉื•ืœื—ืช count ืฉืœื...` ืขื“ื›ื ื” ืืช ื”ืฆื™ืคื™ื™ื” ืฉืœื”: ื”ื›ืคื™ืœ
ืžื™ื™ืฆืจ ืžืงื˜ืข ืขื‘ืจื™ ื›ื‘ืจื™ืจืช ืžื—ื“ืœ, ื•ืœื›ืŸ ืฉืชื™ ืขืžื•ื“ื•ืช ืžืœื•ื•ืช ืขื›ืฉื™ื• ื‘ื”ื•ื“ืขื”.

ืœื”ืกืจื” ื›ืฉื”ืชื™ืงื•ืŸ ื‘ืžื ื•ืข ื™ื’ื™ืข ืœื’ืจืกื” ืฉื”ืชื•ืกืฃ ื ื•ืขืœ โ€” ื”ื›ืชื•ื‘ื•ืช ื‘-docs/engine-gaps.md.
superdoc-bot Bot pushed a commit that referenced this pull request Sep 3, 2026
* fix(layout): honor RTL section column order

Port the public layout changes from #3953 and connect section w:bidi through Orbit's private layout adapter. Preserve RTL document order during column balancing and cover the real DOCX import path.

Co-authored-by: Nathaniel-260 <217926105+Nathaniel-260@users.noreply.github.com>

* fix(layout): anchor RTL graphics to page content

* fix(layout): preserve balanced column ownership

---------

Co-authored-by: Nathaniel-260 <217926105+Nathaniel-260@users.noreply.github.com>

Note: this ports only the public subtree changes from a mixed source commit (18 public paths, 6 non-public paths ignored).

Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: 89684e6e603a46d7fe21a799f7dc91dc48d52edb
Ported-Public-Prefix: superdoc/public
@caio-pizzol

Copy link
Copy Markdown
Contributor

Thanks again for all the work on this, @Nathaniel-260. We integrated your layout changes with the document import wiring needed for real DOCX files and landed the complete column-order fix. Itโ€™s now available in superdoc@2.12.0-next.20 with @superdoc/docx-engine@0.11.0-next.10.

Your work directly shaped the final implementation, and youโ€™re credited as a co-author in the landed commit. Since the change is now on main, Iโ€™m closing this PR as superseded. The linked issue will remain open while we address the two separate selection bugs. Thank you!

@caio-pizzol caio-pizzol closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants